Skip to content

fix(admin): name the access lists, and stop confirming presses that did nothing - #213

Merged
guarzo merged 1 commit into
mainfrom
worktree-access-lists-polish
Aug 10, 2026
Merged

fix(admin): name the access lists, and stop confirming presses that did nothing#213
guarzo merged 1 commit into
mainfrom
worktree-access-lists-polish

Conversation

@guarzo

@guarzo guarzo commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Screenshots in the request showed two things wrong with /admin/access-lists: rows identified by a number the admin has no use for, and an add control parked as an aside on a section head. Fixing the first turned up a third problem underneath it — two controls that confirmed work they had not done.

The name

getWatchedListViews read names off the drift snapshot, which only exists once the list has been read. A list added to the watchlist a minute ago rendered as #4001 — while accessListCatalog, the table the <select> is built from, held its name the whole time.

It now LEFT JOINs the catalog and COALESCEs catalog name over snapshot name. That ordering is not arbitrary: it matches what watchedListName() already does for audit rows (src/services/access-lists.ts:93-104), so the audit log and the page cannot disagree about what a list is called.

The two presses that lied

addWatch uses onConflictDoNothing; removeWatch deletes by id. Both are idempotent, and both actions read "no error" as "it worked":

  • Adding a list another admin had just added → "List added to the watchlist."
  • Removing a row another tab had already removed → "Fleet staging removed from the watchlist."

Neither press wrote an audit row, so the notice was the only account of what happened, and it credited this admin with an act that had already happened without them.

Neither is reachable by clicking alone — the <select> never offers a watched list, and a removed row takes its button with it. Both are reachable exactly the way a real admin reaches them: the page renders, the table changes underneath it, and the press lands against a world that has moved.

Both services now return whether they changed anything. The two actions differ in how they can say so:

  • addWatchAction redirects, and ConfirmNotice carries a sentence and no tone, so its correction is wording alone — ?done=watch-already → "That list was already on the watchlist. Nothing was added."
  • removeWatchAction deliberately does not redirect (a redirect resets the Disclosure state), so it returns a warn ActionOutcome and gets both the words and the tone.

I did not add a tone channel to ConfirmNotice to make these symmetric — it is shared with /account and /admin/sync, and that is a wider change than this task asked for.

The silent refusal

useSubmitGuard refuses a second press while the first is in flight. Where the first press navigates, silence is right. Here it is not: removeWatchAction does not redirect, so the page does not move and the admin watches a press do nothing. Worse, the whole region shares one ConfirmingForm, so the button refused is frequently a different row's than the one in flight.

StopWatching moved out of page.tsx into its own "use client" file to wire onRefused. That is the whole reason for the new file: onRefused is a function, and a server component cannot hand a function to a client one.

Layout

The add control became a field group above the list rather than an aside on the section head — it acts on the collection, not on any row, and "Add to watchlist" is the thing an admin does repeatedly, where "Designate as holder" is done once. The <select> is required with a disabled placeholder, which also fixes a real defect: an untouched submit used to POST with no accessListId and throw invalid_id.

The page adopted .page__head/.page__lede, which let the duplicate .lede rule leave globals.css. monitorSentence's "normal" case gained a real sentence naming the holder and stating what the page compares — deliberately without claiming reads are healthy, since monitorState never sees a readStatus and warn rows render directly beneath that sentence.

Verification

npm test              92 files, 1477 tests passed
npm run typecheck     clean
npm run lint          clean
npm run format:check  All matched files use Prettier code style!
npm run build         succeeded
npx playwright test e2e/access-lists.spec.ts    15 passed

Both new e2e tests were mutation-proved: forcing addWatchAction to always emit done=watch and deleting the if (!removed) branch failed exactly those two tests and nothing else (13 passed). Both mutations were reversed and the restore verified against the diff.

code-reviewer ran clean — admin guard on all four actions, audit writes confined to the branches that actually change state, enqueue-don't-execute boundary untouched, no orphaned comment claims.

Where to look

src/services/access-lists.ts — the two return-value changes are the substance; everything else follows from them. The docblocks there argue why "idempotent" and "confirmed as done" are different claims, which is the point of the whole change.

Summary by CodeRabbit

  • New Features

    • Added a catalog-based control for selecting access lists to monitor.
    • Added clear confirmations when monitoring starts, including when a list is already monitored.
    • Added named controls for stopping monitoring.
    • Improved monitoring descriptions to clarify roster comparisons and list status.
  • Bug Fixes

    • Prevented empty or duplicate monitoring submissions.
    • Added warnings for attempts to stop monitoring lists that are no longer active.
    • Improved access-list names shown in monitoring views and activity records.

…id nothing

The watched-lists page identified rows by `#4001` whenever the list had
never been read, even though the catalog knew its name the whole time.
`getWatchedListViews` now LEFT JOINs `accessListCatalog` and COALESCEs the
catalog name over the snapshot, matching the precedence `watchedListName`
already used for audit rows — so the log and the page cannot disagree about
what a list is called.

Two presses also confirmed work they had not done. `addWatch` and
`removeWatch` are idempotent, and both actions read that as success: adding
a list another admin had just added answered "List added to the watchlist",
and removing a row another tab had already removed answered "removed from
the watchlist". Neither wrote an audit row, so the notice was the only
account of the press, and it credited this admin with someone else's act.
Both services now report whether they changed anything. The add redirects,
so its correction is wording alone (`?done=watch-already`); the remove does
not, so it returns a `warn` outcome.

A refused re-press was silent. `useSubmitGuard` blocks the second press
while the first is in flight, and `removeWatchAction` deliberately does not
redirect, so the page did not move and nothing was said. `StopWatching`
moved into its own client component to wire `onRefused` — a server
component cannot hand a function to a client one.

The add control was rebuilt as a field group above the list rather than an
aside on the section head, since it acts on the collection and not on any
row, and the page adopted `.page__head`/`.page__lede`, which let the
duplicate `.lede` rule leave `globals.css`. `monitorSentence`'s "normal"
case gained a real sentence that names the holder and says what the page
compares, without claiming reads are healthy — it cannot know that.

Both new e2e tests were mutation-proved: reverting either fix fails exactly
the test that covers it.
@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The access-list watch flow now reports successful and no-op changes, uses catalog names when snapshots are absent, validates the add-list form, and separates page header content from watch controls. Unit and E2E tests cover the updated behavior.

Changes

Access-list watch flow

Layer / File(s) Summary
Watch-state service contracts
src/services/access-lists.ts, tests/access-lists-service.test.ts
addWatch and removeWatch return state-change results. No-op operations skip audit records. Watched-list names use catalog data with snapshot fallback. Tests cover these results.
Action outcomes and notices
src/app/admin/access-lists/actions.ts, src/app/admin/access-lists/view.ts, tests/access-lists-view.test.ts
Actions redirect with distinct success, duplicate, removal, and stale-removal outcomes. The normal monitor sentence describes roster comparison without claiming read health.
Catalog and removal controls
src/app/admin/access-lists/page.tsx, src/app/admin/access-lists/stop-watching.tsx, src/app/globals.css
The page adds a required catalog form and uses the shared-form StopWatching component. Header and control styling use the page layout classes.
End-to-end behavior validation
e2e/access-lists.spec.ts
E2E coverage verifies catalog-name fallback, untouched form submission prevention, stale removals, duplicate additions, audit behavior, and .page__lede assertions.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Admin as Access-list page
  participant Action as addWatchAction
  participant Service as addWatch
  participant DB as Watchlist database
  participant Notice as doneNotice

  Admin->>Action: Submit catalog selection
  Action->>Service: Add access-list watch
  Service->>DB: Insert watch row
  DB-->>Service: Inserted or duplicate
  Service-->>Action: Return state-change result
  Action->>Notice: Redirect with outcome marker
  Notice-->>Admin: Render success or already-watched notice
Loading

Possibly related PRs

  • guarzo/authGD#198: Applies a similar no-op mutation reporting pattern to another domain.
  • guarzo/authGD#204: Introduces the access-list monitoring implementation extended by this change.

Poem

Catalog names guide the way,
No-op watches speak plainly today.
Stale rows warn, audits stay still,
Forms wait for a chosen list.
Header notices light the page.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title follows Conventional Commit form and clearly states the user-visible naming and confirmation behavior changes.
Description check ✅ Passed The description clearly explains the changes, rationale, affected files, verification results, and review considerations.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/app/admin/access-lists/actions.ts`:
- Line 41: Replace the existing parseId usage in the access-list actions with
the shared Zod schema, validating formData.get("accessListId") before invoking
addWatch and removeWatch. Reuse the parsed ID for both service calls and
preserve the existing action behavior after validation.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 7be20d16-9fc8-488f-96fb-d2555b1cd592

📥 Commits

Reviewing files that changed from the base of the PR and between e35ee35 and d7ed1d9.

📒 Files selected for processing (9)
  • e2e/access-lists.spec.ts
  • src/app/admin/access-lists/actions.ts
  • src/app/admin/access-lists/page.tsx
  • src/app/admin/access-lists/stop-watching.tsx
  • src/app/admin/access-lists/view.ts
  • src/app/globals.css
  • src/services/access-lists.ts
  • tests/access-lists-service.test.ts
  • tests/access-lists-view.test.ts

const { accountId: actor } = await requireAdminAction();
const accessListId = parseId(formData.get("accessListId"));
await addWatch(getDb(), accessListId, actor);
const added = await addWatch(getDb(), accessListId, actor);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the local action structure and existing Zod conventions.
ast-grep outline src/app/admin/access-lists/actions.ts --items all
rg -n -C 3 --glob '*.{ts,tsx}' 'from "zod"|z\.(object|coerce|number)' src
fd -HI '^package\.json$' -x sh -c 'echo "--- $1"; jq ".dependencies.zod // .devDependencies.zod" "$1"' sh {}

Repository: guarzo/authGD

Length of output: 50370


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- actions.ts ---'
cat -n src/app/admin/access-lists/actions.ts

printf '%s\n' '--- package versions ---'
jq '{dependencies,devDependencies}' package.json | jq 'with_entries(.value |= with_entries(select(.key == "zod" or .key == "next")))'

printf '%s\n' '--- application Zod imports and schemas ---'
rg -n --glob 'src/**/*.{ts,tsx}' 'import \{ z \} from "zod"|Schema|parse\(' src | head -120

Repository: guarzo/authGD

Length of output: 13331


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- actions.ts ---'
cat -n src/app/admin/access-lists/actions.ts

printf '%s\n' '--- package versions ---'
jq '{dependencies,devDependencies}' package.json |
  jq 'with_entries(.value |= with_entries(select(.key == "zod" or .key == "next")))'

printf '%s\n' '--- application Zod imports and schemas ---'
rg -n --glob 'src/**/*.{ts,tsx}' \
  'import \{ z \} from "zod"|Schema|parse\(' src | head -120

Repository: guarzo/authGD

Length of output: 13331


Validate accessListId with Zod before calling the service.

At src/app/admin/access-lists/actions.ts:40 and :85, replace parseId with a shared Zod schema. Parse formData.get("accessListId") before addWatch and removeWatch.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/admin/access-lists/actions.ts` at line 41, Replace the existing
parseId usage in the access-list actions with the shared Zod schema, validating
formData.get("accessListId") before invoking addWatch and removeWatch. Reuse the
parsed ID for both service calls and preserve the existing action behavior after
validation.

Source: Path instructions

@guarzo
guarzo merged commit e495587 into main Aug 10, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant